Skip to content

feat(view): allow finalize bodies to call view functions#3253

Merged
vicsn merged 19 commits into
stagingfrom
mohammadfawaz/finalize_calls_query
May 25, 2026
Merged

feat(view): allow finalize bodies to call view functions#3253
vicsn merged 19 commits into
stagingfrom
mohammadfawaz/finalize_calls_query

Conversation

@mohammadfawaz

@mohammadfawaz mohammadfawaz commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #3252.

A function's finalize body can now call a view function — same-program or imported. Views remain leaves (cannot call other views).

finalize compute_total:
    input r0 as address.public;
    call my_token.aleo/get_balance r0 into r1;  // call a view
    add r1 100u64 into r2;
    set r2 into total[r0];

Views may also declare zero outputs and serve as cross-program preconditions (Aleo analogue of Solidity's function require_member(address) external view { require(...); }):

view require_member:
    input r0 as address.public;
    get.or_use members[r0] false into r1;
    assert.eq r1 true;

finalize transfer:
    input r0 as address.public;
    call vw_acl.aleo/require_member r0;   // no `into`; aborts the tx if the guard fails
    ...

What's enforced

  • Construction: Finalize::add_command permits Call; call.dynamic rejected via Command::is_dynamic_call. Constructors still forbid call entirely. View inputs/commands/outputs are all many0 (matches function); body constraints (no record-touching ops, no state writes, no async/await/call/rand.chacha) are enforced by ViewCore::add_command.
  • Type-check: Opcode::Call(_) in a finalize body is allowed only when the target resolves to a view; each operand's type is checked against the view's declared input type, and the destination count must match the view's output count. call.dynamic is bailed in the finalize type-checker.
  • Runtime: Call::finalize loads inputs from the caller's FinalizeRegisters, dispatches the view body via a new StackTrait::evaluate_view hook against the live finalize store with the caller's FinalizeGlobalState, and writes outputs back. A view-body failure surfaces as a finalize rejection.
  • Cost: cost_per_command folds the callee's view_cost_for_single_view into the caller's finalize cost, keeping TRANSACTION_SPEND_LIMIT as the combined bound.
  • V15 gate: Program::contains_v15_syntax flags any call in a finalize body; arity relaxations ride the same gate.

@mohammadfawaz mohammadfawaz marked this pull request as draft May 11, 2026 21:00
@mohammadfawaz mohammadfawaz self-assigned this May 11, 2026
@mohammadfawaz mohammadfawaz force-pushed the mohammadfawaz/finalize_calls_query branch from a3f40b7 to cf6b381 Compare May 13, 2026 13:57
@mohammadfawaz mohammadfawaz changed the title feat(query): allow finalize bodies to call query functions feat(view): allow finalize bodies to call view functions May 13, 2026
@mohammadfawaz mohammadfawaz changed the base branch from staging to mohammadfawaz/rename_query_to_view May 13, 2026 13:57
Base automatically changed from mohammadfawaz/rename_query_to_view to staging May 13, 2026 17:17
Adds Eth-`view`-style invocation: a function's `finalize` body can call a
view function (same-program or imported cross-program). Views themselves
remain leaves — they still reject `is_call` at construction, so no
recursion.

Changes:
- Cache view `FinalizeTypes` on `Stack` (previously recomputed per call).
- Loosen `Finalize::add_command` to permit `call`; keep `call.dynamic`
  rejected. New `Command::is_dynamic_call` helper.
- Type-check (`check_instruction_opcode`) resolves the target and allows
  `Opcode::Call` only when it lands on a view. `Call::output_types`
  gains a view branch alongside closure / function.
- Runtime dispatch: `finalize_command_except_await` special-cases
  `Command::Instruction(Instruction::Call(_))` and defers to a new
  `evaluate_call_to_view` helper that loads inputs from the caller's
  registers, runs the view body against the live store with the caller's
  inherited `FinalizeGlobalState`, and writes outputs to the caller's
  destination registers.
- Cost rollup: `cost_per_command` now folds the called view's
  `view_cost_for_single_view` into the caller's finalize cost, so the
  per-function `TRANSACTION_SPEND_LIMIT` check covers callee compute too.
- V15 syntax gate: `Program::contains_v15_syntax` flags any `call`
  inside a finalize body (pre-V15 finalize forbade `call` entirely).
- The view module is now unconditionally compiled (the in-block call
  path doesn't need the `history` feature); only the
  `evaluate_view_at_height` public API and `HistoricFinalizeStore`
  remain gated.
@mohammadfawaz mohammadfawaz force-pushed the mohammadfawaz/finalize_calls_query branch from cf6b381 to 1e6645e Compare May 13, 2026 17:24
Comment thread synthesizer/process/src/lib.rs Outdated
Comment thread synthesizer/program/src/finalize/mod.rs Outdated
Mohammad Fawaz added 3 commits May 14, 2026 13:36
Drop the verbose explanation on the unconditional 'view' module declaration
and correct the rationale for forbidding 'call.dynamic' in finalize: the
real blocker is that we have not yet designed dynamic spend / gas tracking
for runtime-resolved targets.
Restore Call::output_types to its original transition-only behavior and
introduce Call::output_types_for_view for the finalize-side view-call
type-check. Finalize-types initialize dispatches Instruction::Call to the
new helper, making the API surface self-document the split and removing
the risk of a future transition-path caller accidentally accepting a view
target through the generic output_types entry.
@mohammadfawaz mohammadfawaz marked this pull request as ready for review May 14, 2026 18:38
@mohammadfawaz mohammadfawaz requested a review from vicsn May 14, 2026 18:38
@mohammadfawaz mohammadfawaz changed the title feat(view): allow finalize bodies to call view functions feat(view): allow finalize bodies to call view functions May 14, 2026
Mohammad Fawaz added 2 commits May 14, 2026 15:34
Bail explicitly on `CallDynamic` in the finalize type-checker, add a per-operand input-type check in `Call::output_types_for_view`, and promote the post-view `finalize_operations.is_empty()` invariant to a release-time `ensure!`. Tests cover destination-count and input-type mismatches at deploy, runtime view failure, and branch interactions.
Relax `many1` → `many0` on view outputs in the parser, `FromBytes`/`ToBytes`, and `Program::add_view`. Enables Solidity-style cross-program precondition guards from finalize; rides the existing V15 gate.
@mohammadfawaz mohammadfawaz force-pushed the mohammadfawaz/finalize_calls_query branch from 22250b3 to 0afb918 Compare May 14, 2026 19:35
Mohammad Fawaz added 6 commits May 14, 2026 15:38
Relax `many1` → `many0` on view commands in the parser, `FromBytes`/`ToBytes`, and `Program::add_view`. Brings view arity in line with `function` (all-`many0`) and removes an arbitrary restriction; no use case in mind, just consistency. Rides the existing V15 gate.
…t_types"

This reverts commit cde8cb9, folding the
view-target branch back into `Call::output_types`. The per-operand input-type
check introduced afterwards (ef0b187) is preserved inside the view branch.
Replace the special case in `finalize_command_except_await` with a real
`Call::finalize` that loads inputs from the caller's `FinalizeRegisters`,
dispatches the view body through a new `StackTrait::evaluate_view` hook
(implemented on `Stack` as a thin wrapper around `evaluate_view_inner`),
and writes outputs back. The transition-side `evaluate_call_to_view` /
`run_view_call` helpers in `process::view` are no longer needed and are
removed.

To plumb the finalize store into `Call::finalize`, `Instruction::finalize`
gains a `store: &impl FinalizeStoreTrait<N>` parameter and tightens the
registers bound from `RegistersTrait<N>` to `FinalizeRegistersState<N>`
(the latter implies the former). Each per-instruction `finalize`
impl ignores the new `_store` argument; only `Call::finalize` uses it.

The instruction-level tests in `synthesizer/program/tests/instruction/`
pass a new `NoopFinalizeStore` shim — a `bail!`-only `FinalizeStoreTrait`
fixture — to satisfy the signature without pulling `snarkvm-ledger-store`
into the program crate's dev-deps for fixtures that never touch the store.
…finalize

Lets tests pass `None` instead of a dummy store fixture. `Call::finalize` errors when the store is missing.

@vicsn vicsn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to update check_upgrade_is_valid. It's a pity that logic is so hidden and not tightly integrated.

We currently prevent a function from defining more than MAX_TRANSITIONS static calls in get_minimum_number_of_calls. Will we run into DoS trouble if someone defines a finalize scope calling into MAX_COMMANDS views? Defining a MAX_CALLS = MAX_TRANSITIONS is probably acceptable UX.

We could also consider changing check_program_is_well_formed to allow a deployment of programs with only views, not sure if this will break existing assumptions in the code. For example, deployments could then have 0 verifying keys. I'm leaning towards leaving this as future work.

vicsn and others added 2 commits May 22, 2026 11:52
Adds `MAX_CALLS = 32` (mirrors `MAX_TRANSITIONS`) enforced in `Finalize::add_command`, and extends `check_upgrade_is_valid` to require each old view's input/output types in the new program.
@mohammadfawaz

Copy link
Copy Markdown
Collaborator Author

Re: vicsn's review

We need to update check_upgrade_is_valid. It's a pity that logic is so hidden and not tightly integrated.

We currently prevent a function from defining more than MAX_TRANSITIONS static calls in get_minimum_number_of_calls. Will we run into DoS trouble if someone defines a finalize scope calling into MAX_COMMANDS views? Defining a MAX_CALLS = MAX_TRANSITIONS is probably acceptable UX.

We could also consider changing check_program_is_well_formed to allow a deployment of programs with only views, not sure if this will break existing assumptions in the code. For example, deployments could then have 0 verifying keys. I'm leaning towards leaving this as future work.

Addressed in fae8a28: MAX_CALLS = 32 enforced in Finalize::add_command, and check_upgrade_is_valid now requires each old view to remain in the new program with the same input/output types (body mutable, same policy as functions/finalize).

Filed #3271 to track the view-only deployment relaxation as future work.

@vicsn vicsn merged commit ef52841 into staging May 25, 2026
6 checks passed
@vicsn vicsn deleted the mohammadfawaz/finalize_calls_query branch May 25, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Allow query to be called from function or finalize

2 participants